An exploration of nighttime lights and the Dark Sky Movement in the US

Author

Carly Staebell

Published

December 4, 2025

Introduction

Electric lights have benefited society immensely since their invention in 1879. Modern work, recreation, and transportation are made possible largely due to artificial lighting. However, when artificial outdoor lighting becomes inefficient and unnecessary, it is known as light pollution, with negative impacts on both wildlife and humans [1].

Concerns about the impacts of light pollution on astronomical observation led to the 1988 formation of the nonprofit now known as DarkSky International [2]. Since then, DarkSky has grown into a recognized global authority on light pollution with a mission to safeguard communities and wildlife against light pollution [3]. The organization’s International Dark Sky Places program allows communities, parks, and protected areas to seek certification for efforts to preserve and protect their dark skies [4]. While many communities implement outdoor lighting policies without seeking recognition, a DarkSky certification is an indicator of places that have made dark sky conservation a priority.

This project aims to explore trends in nighttime light levels first from a national contiguous US perspective, and through more detailed case studies of individual cities. Three DarkSky certified communities [4] with different sizes and designation dates have been selected:

  • Flagstaff, Arizona: Population ~77,000, designated 2001;
  • Homer Glen, Illinois: Population ~25,000, designated 2011;
  • Bee Cave, Texas: Population ~9,000, designated 2023.

These three communities were each paired with a community of similar size that has fewer, if any, ordinances controlling light pollution. Each community pair was examined to see if there are observable differences in nighttime radiance.

Materials and methods

The following data were used.

  • NASA’s Black Marble annual nighttime lights (VNP46A4 data product) [5] sourced via the blackmarbler package [6]
    • Spatial resolution: 500 m
  • US Census Bureau TIGER data [7] sourced via the tigris package [8]
  • US Census Bureau American Community Survey (ACS) population data [9] sourced via the tidycensus package [10]
  • MODIS Type 1 yearly land use/land cover (MCD12Q1 data product) sourced via AppEEARS [11]
    • Spatial resolution: 500 m
Code
library(tidyverse)
library(leaflet)
library(kableExtra)
library(htmlwidgets)
library(widgetframe)
knitr::opts_chunk$set(widgetframe_widgets_dir = 'widgets' ) 
knitr::opts_chunk$set(cache=TRUE)  # cache the results for quick compiling

library(blackmarbler)
library(sf)
library(terra)
library(tigris)
library(tidyterra)
library(tidycensus)
library(patchwork)
library(piggyback)
library(ggiraph)
library(scales)

Download and clean all required data

National and statewide nighttime lights

A US Census TIGER shapefile was downloaded to provide geographic state boundaries. A separate script was used to download and save annual nighttime lights rasters for the contiguous US (‘black_marble_data_download.R’). The script was also used to obtain extracted nighttime lights values aggregated at the national and state levels. Nighttime lights are provided as radiance values with units of \(\frac{n W}{cm^2 sr}\).

Code
# Download and process CONUS NTL data
# Define national spatial extent
nonconus <- c("Guam", "Hawaii", "Alaska",
              "Commonwealth of the Northern Mariana Islands",
              "United States Virgin Islands", "American Samoa", "Puerto Rico")

states_sf <- states() |>
  filter(!NAME %in% nonconus) |>
  select(NAME, geometry) |>
  st_transform(crs = "epsg:4326")

# Dissolve individual state boundaries to get CONUS outline
conus <- st_union(states_sf)|>
  st_as_sf()

## Read in national raster data for 2024 (to be used for leaflet map later)
pb_download("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t2024.tif", 
            repo = "cstaebell/final-project-cstaebell")

us_2024_rast <- rast("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t2024.tif") |>
  mask(conus)

# create log transformed version for plotting
log_us_2024 <- app(us_2024_rast, fun = log1p)

# Downsample log data for map (commented out since file written)
#aggregate(log_us_2024, fact = 4, fun = "median", filename = "data/log_us_2024_downsampled.tif", overwrite = TRUE)

#---
# Download and process annual US mean NTL values
#---
years = 2014:2024
conus_means = numeric(length(years))

for (i in 1:length(years)) {
  pb_download(paste("CONUS_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = ""), repo = "cstaebell/final-project-cstaebell")
  annual_mean <- readRDS(paste("CONUS_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = "")) |>
    pull(ntl_mean)
  
  conus_means[i] <- annual_mean
}

conus_df <- data.frame(year = years, mean_ntl = conus_means)

#---
# Download and process mean NTL values for states
#---
state_means <- matrix(nrow = 49, ncol = length(years))

# Read in annual data for states and create a matrix of state means
for (i in 1:length(years)) {
  pb_download(paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = ""), repo = "cstaebell/final-project-cstaebell")
  annual_mean <- readRDS(paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = ""))
  
  state_means[,i] <- annual_mean[,2]

  # check that state names are ordered the same way for each year
  if (i == 1) {
    state_names <- annual_mean[,1]
  } else {
    if (sum(state_names != annual_mean[,1]) != 0) {
      print("Different state names")
    }
  }
}

# Create data frame of state means and add percent change column
state_means_df <- data.frame(state_means)
colnames(state_means_df) <- paste("ntl_", years, sep = "")
state_means_df <- state_means_df |>
  mutate(state = state_names, 
         pct_change = (ntl_2024 - ntl_2014) / ntl_2014 * 100)


# Combine state NTL data with geographic data
states_ntl <- states_sf |>
  inner_join(state_means_df, by = c("NAME" = "state"))

Case Studies

For case study analyses, population data and local geographies for each Dark Sky Place were downloaded from the US Census.

Code
# Download population data
# US population to search for companion cities
us_pop <- get_acs(geography = "place", state = NULL, 
                  variables = "B01003_001", key = census_api_key)

# State populations for the states in which the selected DarksKy communities reside
az_pop <- get_acs(geography = "place", state = "AZ", 
                         variables = "B01003_001", key = census_api_key)

il_pop <- get_acs(geography = "place", state = "IL", 
                         variables = "B01003_001", key = census_api_key)

tx_pop <- get_acs(geography = "place", state = "TX", 
                         variables = "B01003_001", key = census_api_key)

# Download local geographies: selected Dark Sky Places
flagstaff <- places(state = "AZ", cb = TRUE, year = 2024) |>
  filter(NAME == "Flagstaff") |>
  st_transform(crs = "epsg:4326") |>
  left_join(us_pop, by = "GEOID")

homerglen <- places(state = "IL", cb = TRUE, year = 2024) |>
  filter(NAME == "Homer Glen") |>
  st_transform(crs = "epsg:4326") |>
  left_join(us_pop, by = "GEOID")

beecave <- places(state = "TX", cb = TRUE, year = 2024) |>
  filter(NAME == "Bee Cave") |>
  st_transform(crs = "epsg:4326") |>
  left_join(us_pop, by = "GEOID")

ACS population data were filtered to return a listing of candidate municipalities with populations within 1% of each Dark Sky community population. Final Comparison cities were selected based on population match and state, with preference given to cities within states that are not known to have laws regarding outdoor lighting geared toward dark sky protection [12].

Code
# Get listing of suitable comparison cities
flagstaff_sim_pop <- us_pop |> filter(estimate >= (flagstaff$estimate - 0.005*flagstaff$estimate) 
                                & estimate <= (flagstaff$estimate + 0.005*flagstaff$estimate)) |>
  separate(NAME, into = c("city", "state"), sep = ", ")

homerglen_sim_pop <- us_pop |> filter(estimate >= (homerglen$estimate - 0.005*homerglen$estimate) 
                                & estimate <= (homerglen$estimate + 0.005*homerglen$estimate)) |>
  separate(NAME, into = c("city", "state"), sep = ", ")

beecave_sim_pop <- us_pop |> filter(estimate >= (beecave$estimate - 0.005*beecave$estimate) 
                                & estimate <= (beecave$estimate + 0.005*beecave$estimate)) |>
  separate(NAME, into = c("city", "state"), sep = ", ")

# Download/process comparison city data
 # Flagstaff counterpart
scranton <- places(state = "PA", cb = TRUE, year = 2024) |>
  filter(NAME == "Scranton") |>
  left_join(us_pop, by = "GEOID") |>
  st_transform(crs = "epsg:4326")

 # Homer Glen counterpart
belvidere <- places(state = "IL", cb = TRUE, year = 2024) |>
  filter(NAME == "Belvidere") |>
  left_join(us_pop, by = "GEOID") |>
  st_transform(crs = "epsg:4326")

 # Bee Cave counterpart
clanton <- places(state = "AL", cb = TRUE, year = 2024) |>
  filter(NAME == "Clanton") |>
  left_join(us_pop, by = "GEOID") |>
  st_transform(crs = "epsg:4326")

The final city pairs are as follows:

Pair Community Population
A Flagstaff, AZ 76,333
A Scranton, PA 76,074
B Homer Glen, IL 24,516
B Belvidere, IL 24,510
C Bee Cave, TX 8,861
C Clanton, AL 8,862

After selecting cities, the annual US nighttime lights rasters were read in and subsetted to create a raster and dataframe of mean values for each city. Since simply looking at annual means for each city may be more of a proxy for overall development than for lighting policy, land use/land cover data1 were also brought in to help make comparisons across communities more meaningful. The land cover rasters were cropped to each city, and then the urban areas were isolated, allowing for calculation of annual mean nighttime lights from urban areas only.

Code
# Nighttime lights and land cover data are in a single chunk to avoid external pointer errors when rendering

#---
# Nighttime lights
#---
# Subset nighttime lights rasters to each city
city_names <- c("flagstaff", "homerglen", "beecave", "scranton", 
                           "belvidere", "clanton")
cities <- list(flagstaff, homerglen, beecave, scranton, belvidere, clanton)
city_rasters <- vector("list", length = length(cities))

# Load files for each year
for (i in 1:length(years)){
  pb_download(paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t", 
                     years[i], ".tif", sep = ""), repo = "cstaebell/final-project-cstaebell")
  rast_file <- paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t", 
                     years[i], ".tif", sep = "")
  
  year_rast <- rast(rast_file)
  
  # Loop over cities
  for (j in 1:length(cities)) {
    # Crop and mask to city extent
    current_rast <- year_rast |>
      terra::crop(cities[[j]]) |>
      mask(cities[[j]])
    
    # Create list entry during first iteration, then append in subsequent iterations
    if (i == 1) {
    city_rasters[[j]] <- list(current_rast)
    } else {
    city_rasters[[j]][[length(city_rasters[[j]]) + 1]] <- current_rast
    }
  }
}

# Stack city lists 
city_rasters <- lapply(city_rasters, rast)

# Create separate rasters for each city 
city_var_names <- paste0(city_names, "_rast")
for (c in 1:length(city_var_names)) {
  assign(city_var_names[c], city_rasters[[c]])
}

# Calculate annual means
annual_means <- vector("list", length = length(cities))
for (i in 1:length(city_rasters)) {
  ntl_vals <- data.frame(values(city_rasters[[i]])) |>
    summarize(across(t2014:t2024, ~ mean(.x, na.rm = TRUE)))
  
  annual_means[[i]] <- ntl_vals
}

# Create mean dataframes for each city
city_df_var_names <- paste0(city_names, "_means")
for (i in 1:length(annual_means)) {
  df <- annual_means[[i]] |>
    pivot_longer(cols = t2014:t2024) |>
    mutate(name = years) |>
    rename(year = name, mean_ntl = value)
  
  assign(city_df_var_names[i], df)
}

#---
# Land cover data
#---
# Download land cover data 
pb_download("MCD12Q1.061_LC_Type1_doy2019001000000_aid0001.tif", 
            repo = "cstaebell/final-project-cstaebell")
pb_download("MCD12Q1.061_LC_Type1_doy2020001000000_aid0001.tif", 
            repo = "cstaebell/final-project-cstaebell")
land_use_rast_2019 <- rast("MCD12Q1.061_LC_Type1_doy2019001000000_aid0001.tif")
land_use_rast_2020 <- rast("MCD12Q1.061_LC_Type1_doy2020001000000_aid0001.tif")

# Subset land cover rasters to each city
land_use_rasters <- vector("list", length = length(cities))
urban_ntl_dfs <- vector("list", length = length(cities))
urban_ntl_vals <- vector("list", length = length(cities))
group_assign <- c("Dark Sky", "Dark Sky", "Dark Sky", "Comparison", "Comparison", "Comparison")
pair_assign <- c("A", "B", "C", "A", "B", "C")


for (i in 1:length(cities)) {
  # Specify which data to use (2019 for all cities but Clanton)
  if (i == 6) {
    land_use_rast <- land_use_rast_2020
  } else {
    land_use_rast <- land_use_rast_2019
  }
  
  # Crop and mask to city extent
  current_rast <- land_use_rast |>
    terra::crop(cities[[i]]) |>
    mask(cities[[i]])
  # Save to list for future use 
  land_use_rasters[[i]] <- current_rast
  
  # Mask all but urban land use (land use code = 13)
  urban_area <- current_rast
  urban_area[urban_area != 13] <- NA
  city_urban <- city_rasters[[i]] |>
    mask(urban_area) 
  
  # Calculate mean NTL for urban areas 
  urban_ntl_dfs[[i]] <- data.frame(values(city_urban)) |>
    summarize(across(t2014:t2024, ~ mean(.x, na.rm = TRUE))) |>
    pivot_longer(cols = t2014:t2024) |>
    mutate(name = years) |>
    rename(year = name, mean_urban_ntl = value)
  
  # Compile values for statistical tests
  # put these outside the loop when done. also replace paste0 with city_name
  
  urban_ntl_vals[[i]] <- data.frame(values(city_urban)) |>
    pivot_longer(cols = t2014:t2024) |>
    filter(!is.na(value)) |>
    mutate(city = city_names[[i]], group = group_assign[i], pair = pair_assign[i])
}

# Create separate dataframes from list object
urban_ntl_var_names <- paste0(city_names, "_urban_ntl")

for (i in 1:length(urban_ntl_dfs)) {
  assign(urban_ntl_var_names[i], urban_ntl_dfs[[i]])
}


# Prepare data for sample land cover plots
# Define land cover encoding
Land_Cover_Type_1 <- c(
  "Water" = 0, "Evergreen Needleleaf forest" = 1, "Evergreen Broadleaf forest" = 2,
  "Deciduous Needleleaf forest" = 3, "Deciduous Broadleaf forest" = 4, "Mixed forest" = 5,
  "Closed shrublands" = 6, "Open shrublands" = 7, "Woody savannas" = 8, "Savannas" = 9,
  "Grasslands" = 10, "Permanent wetlands" = 11, "Croplands" = 12, "Urban & built-up" = 13,
  "Cropland/Natural vegetation mosaic" = 14, "Snow & ice" = 15, "Barren/Sparsely vegetated" = 16
)

lcd <- data.frame(
  ID = Land_Cover_Type_1,
  landcover = names(Land_Cover_Type_1),
  stringsAsFactors = FALSE
)

# Create data frames for plotting
scranton_lulc <- as.data.frame(land_use_rasters[[4]], xy = TRUE) |>
  left_join(lcd, by = join_by(MCD12Q1.061_LC_Type1_doy2019001000000_aid0001 == ID))

# Since Scranton has more landcover types, use its types to join with Flagstaff for a consistent color scheme
lcd_reduced <- lcd |>
  filter(landcover %in% unique(scranton_lulc$landcover))
  
flagstaff_lulc <- as.data.frame(land_use_rasters[[1]], xy = TRUE) |>
  right_join(lcd_reduced, by = join_by(MCD12Q1.061_LC_Type1_doy2019001000000_aid0001 == ID))

Paired data frames were created from the processed data to facilitate plotting and visualizations.

Code
# Annual means
# Pair A: Flagstaff and Scranton
flagstaff_means_label <- flagstaff_means |>
  mutate(city = "Flagstaff", pair = "A", group = "Dark Sky")
scranton_means_label <- scranton_means |>
  mutate(city = "Scranton", pair = "A", group = "Comparison")
pair_a_means <- bind_rows(flagstaff_means_label, scranton_means_label)

# Pair B: Homer Glen and Belvidere
homerglen_means_label <- homerglen_means |>
  mutate(city = "Homer Glen", pair = "B", group = "Dark Sky")
belvidere_means_label <- belvidere_means |>
  mutate(city = "Belvidere", pair = "B", group = "Comparison")
pair_b_means <- bind_rows(homerglen_means_label, belvidere_means_label)

# Pair C: Bee Cave and Clanton
beecave_means_label <- beecave_means |>
  mutate(city = "Bee Cave", pair = "C", group = "Dark Sky")
clanton_means_label <- clanton_means |>
  mutate(city = "Clanton", pair = "C", group = "Comparison")
pair_c_means <- bind_rows(beecave_means_label, clanton_means_label)

all_pair_means <- bind_rows(pair_a_means, pair_b_means, pair_c_means)
pair_names <- c("Flagstaff & Scranton", "Homer Glen & Belvidere", "Bee Cave & Clanton")
names(pair_names) <- c("A", "B", "C")

## Urban means
# Pair A: Flagstaff and Scranton
flagstaff_urban_label <- flagstaff_urban_ntl |>
  mutate(city = "Flagstaff", pair = "A", group = "Dark Sky")
scranton_urban_label <- scranton_urban_ntl |>
  mutate(city = "Scranton", pair = "A", group = "Comparison")
pair_a_urban <- bind_rows(flagstaff_urban_label, scranton_urban_label)

# Pair B: Homer Glen and Belvidere
homerglen_urban_label <- homerglen_urban_ntl |>
  mutate(city = "Homer Glen", pair = "B", group = "Dark Sky")
belvidere_urban_label <- belvidere_urban_ntl |>
  mutate(city = "Belvidere", pair = "B", group = "Comparison")
pair_b_urban <- bind_rows(homerglen_urban_label, belvidere_urban_label)

# Pair C: Bee Cave and Clanton
beecave_urban_label <- beecave_urban_ntl |>
  mutate(city = "Bee Cave", pair = "C", group = "Dark Sky")
clanton_urban_label <- clanton_urban_ntl |>
  mutate(city = "Clanton", pair = "C", group = "Comparison")
pair_c_urban <- bind_rows(beecave_urban_label, clanton_urban_label)

all_pair_urban <- bind_rows(pair_a_urban, pair_b_urban, pair_c_urban)

Statistical testing was done on the nonaggregated urban lights values. Although there are thousands of samples, radiance data exhibit a heavy right skew. The Shapiro-Wilk test was used to confirm non-normality. Since the data are not normal, a Wilcoxon test was used for each community pair to test if the Comparison group radiance is greater than that of Dark Sky group.

Code
# Prep values for statistical testing
pair_a_uvals <- bind_rows(urban_ntl_vals[[1]], urban_ntl_vals[[4]])
pair_b_uvals <- bind_rows(urban_ntl_vals[[2]], urban_ntl_vals[[5]])
pair_c_uvals <- bind_rows(urban_ntl_vals[[3]], urban_ntl_vals[[6]])

for (i in 1:length(urban_ntl_vals)) {
  shapiro_test <- shapiro.test(urban_ntl_vals[[i]]$value)
  if (shapiro_test$p.value < 0.05) {
    print("Reject normality")
  } else {
    print("Fail to reject normality")
  }
}
[1] "Reject normality"
[1] "Reject normality"
[1] "Reject normality"
[1] "Reject normality"
[1] "Reject normality"
[1] "Reject normality"
Code
wilcox_a <- wilcox.test(value ~ group, data = pair_a_uvals, alternative = "greater")

wilcox_b <- wilcox.test(value ~ group, data = pair_b_uvals, alternative = "greater")

wilcox_c <- wilcox.test(value ~ group, data = pair_c_uvals, alternative = "greater")

Results

Case Studies

Code
# Leaflet prep
fs_coord <- st_centroid(flagstaff) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Flagstaff", pair = "A", group = "Dark Sky")

sc_coord <- st_centroid(scranton) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Scranton", pair = "A", group = "Comparison")

hg_coord <- st_centroid(homerglen) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Homer Glen", pair = "B", group = "Dark Sky")

bv_coord <- st_centroid(belvidere) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Belvidere", pair = "B", group = "Comparison")

bc_coord <- st_centroid(beecave) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Bee Cave", pair = "C", group = "Dark Sky")

cl_coord <- st_centroid(clanton) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Clanton", pair = "C", group = "Comparison")

markers_df <- bind_rows(fs_coord, sc_coord, hg_coord, bv_coord, bc_coord, cl_coord)
markers_df$pair <- as.factor(markers_df$pair)

log_us_2024_ds <- rast("data/log_us_2024_downsampled.tif")

vals <- values(log_us_2024_ds, mat = FALSE)
midpoint <- 3.5

breaks <- c(min(vals, na.rm = TRUE),
            seq(min(vals, na.rm = TRUE), midpoint, length.out = 5)[-1],
            seq(midpoint, max(vals, na.rm = TRUE), length.out = 5)[-1])

pal <- colorBin(palette = c("black", "yellow", "red"),
                domain = vals,
                bins = breaks,
                na.color = "transparent")


city_color_pal <- colorFactor(palette = c("firebrick", "royalblue3", "green4"),
                           domain = markers_df$pair)

city_popup <- paste0("<b>", markers_df$city, "</b><br/>",
                     "<i>", markers_df$group, " Community</i><br/>",
                     "Pair: ", markers_df$pair)

# Leaflet map
leaflet() |>
  addProviderTiles("CartoDB") |>
  addRasterImage(log_us_2024_ds, group = "US NTL", colors = pal) |>
  addCircleMarkers(data = markers_df, ~X, ~Y, fillColor = ~city_color_pal(pair), 
                   fillOpacity = 0.9, color = "black", 
                   label = ~as.character(city), popup = city_popup)

Four of the six case study cities exhibit an increasing trend similar to that of the national mean. However, the Pair A cities, Flagstaff and Scranton, show decreasing tendencies. Also notable from this plot are the large differences in radiance values within pairs, despite controlling for population during the city selection process.

Code
# Plot of annual means
ggplot(data = all_pair_means) +
  geom_line(aes(x = year, y = mean_ntl, color = pair, linetype = group), linewidth = 1) +
  facet_wrap(~ pair, labeller = labeller(pair = pair_names)) +
  scale_linetype_manual(values = c("Dark Sky" = "solid", "Comparison" = "dashed")) +
  scale_color_manual(values = c("A" = "firebrick", "B" = "royalblue3", "C" = "green4")) +
  labs(x = "Year",
       y = expression("Mean Radiance (nW cm"^-2~"sr"^-1*")"),
       title = "Mean Annual Radiance",
       linetype = "City Type",
       color = "Pair") +
  theme_light() +
  theme(strip.text.x = element_text(color = "black"),
        plot.title = element_text(hjust = 0.5))

A quick look at the land use for Flagstaff compared to Scranton helps to explain the vast difference in mean nighttime lights between the two cities. Flagstaff contains much more undeveloped land like grassland and savannas, whereas most of Scranton is classified as urban and built up.

Code
# Sample comparison of land cover
fs_lulc_plot <- ggplot(flagstaff_lulc, aes(x = x, y = y)) +
  geom_tile(aes(fill = landcover)) +
  coord_quickmap() +
  theme_light() +
  scale_fill_viridis_d() +
  labs(x = NULL,
       y = NULL,
       title = "Flagstaff Land Cover",
       fill = NULL)
  
sc_lulc_plot <- ggplot(scranton_lulc, aes(x = x, y = y)) +
  geom_tile(aes(fill = landcover)) +
  coord_quickmap() +
  theme_light() +
  scale_fill_viridis_d() +
  labs(x = NULL,
       y = NULL,
       title = "Scranton Land Cover",
       fill = NULL) +
  scale_x_continuous(breaks = seq(-75.7, -75.6, by = 0.05))

fs_lulc_plot + sc_lulc_plot + plot_layout(guides = "collect") & theme(legend.position = "bottom")

Given the imbalances in land cover between different cities, restricting analysis to urban areas is a more effective means of comparison. (Proceeding this way introduces an assumption that any lighting policy effects outside of non-built-up areas are dwarfed by those on urban areas.) From the plots of urban mean radiance, we can see that the radiance for the Dark Sky Places is generally lower than for their paired cities.

Code
# Urban mean NTL plot
ggplot(data = all_pair_urban) +
  geom_line(aes(x = year, y = mean_urban_ntl, color = pair, linetype = group), linewidth = 1) +
  facet_wrap(~ pair, labeller = labeller(pair = pair_names)) +
  scale_linetype_manual(values = c("Dark Sky" = "solid", "Comparison" = "dashed")) +
  scale_color_manual(values = c("A" = "firebrick", "B" = "royalblue3", "C" = "green4")) +
  labs(x = "Year",
       y = expression("Mean Radiance (nW cm"^-2~"sr"^-1*")"),
       title = "Mean Annual Radiance from Urban Areas",
       linetype = "City Type",
       color = "Pair") +
  theme_light() +
  theme(strip.text.x = element_text(color = "black"),
        plot.title = element_text(hjust = 0.5))

The results of the Wilcoxon test suggest that the observed differences between the Dark Sky communities and their Comparison counterparts are statistically significant.

Pair p-value
A <0.0001
B <0.0001
C 0.00591

Discussion of lighting policies

Pair A

As the home of two major observatories, Flagstaff has been at the forefront of outdoor lighting policy since 1958. The city’s lighting ordinance aims to encourage lighting practices and systems that will minimize light pollution and trespass while still maintaining nighttime safety, security, and productivity. The code includes comprehensive standards for maximum total outdoor light output, shielding requirements, time limits, sign illumination, and lamp types. [13]

Scranton’s Zoning Ordinance includes one short section devoted to lighting, which mainly focuses on curbing light trespass and nuisance glare. Maximum illumination levels are defined for two lighting levels. [14]

Pair B

Homer Glen’s outdoor lighting ordinance is rooted in the desire to preserve rural character and aesthetic value. The ordinance establishes maximum light output and inclination for various outdoor lighting applications, permitted hours for lighting, shielding requirements, and bulb types. [15]

Similar to Scranton, Belvidere includes exterior lighting standards in their code of ordinances but does so for the purposes of promoting traffic safety and preventing nuisances. The ordinance sets limits on illumination intensity and hours of illumination outside of buildings, plus some requirements for light orientation and shielding. However, these provisions are not as strict or clearly defined as those in the Homer Glen ordinanace. [16]

Pair C

Bee Cave’s Unified Development Code contains a comprehensive lighting ordinance that appears similar to Flagstaff’s. The code addresses lighting controls, lumen caps, shielding and cut-off standards, sign illumination restrictions, and more. [17]

The code of ordinances for Clanton lacks a section devoted to lighting. Still, lights are mentioned in relation to other regulated items like signs and gas stations. The ordinance for gas stations includes a provision to prevent light trespass, but otherwise, outdoor lighting policy does not appear to be a priority for the city. [18]

Conclusions

Nighttime light emissions from the contiguous US show an increasing trend. Some states show drastic growth in radiance from 2014-2024, while others, particularly in the Midwest and Northeast, show more moderate changes.

After controlling for population and urban area, the nighttime lights of DarkSky-certified communities appear lower than those of similar cities that have not made outdoor lighting policy a priority. Bee Cave, the most recently designated Dark Sky place, is most similar to its paired community. It is possible that Bee Cave is still transitioning fixtures and practices to align with its newer commitment to dark sky preservation.

Limitations and further opportunities:

  • Radiance of nighttime lights is not a direct measure of light pollution. Some cities are switching from orange high pressure sodium lamps to white LEDs. VIIRS spectral range does not completely cover the blue light peak of many modern white LEDs, which can lead to somewhat misleading decreases in satellite-observed radiance [19].
  • Cities were paired based on same population as of 2023 ACS census. It is likely that the populations were not the same for all other years due to different rates of population growth.
  • One year of land cover data was assumed to represent each city’s land cover over the 10-year span, which is a simplification. Future analysis could incorporate a more nuanced use of land cover data.
  • Similarly, annual population data could be incorporated to further explore the relationship between nighttime lights and population.
  • Analysis could be expanded to additional cities.

References

[1] Chepesiuk, R. (2009). Missing the dark: health effects of light pollution. Environmental Health Perspectives, 117(1), A20+. https://www.jstor.org/stable/40066663.

[2] Hunter, T., (2023). The birth of DarkSky (IDA) and a lifelong mission fighting light pollution. DarkSky. https://darksky.org/news/the-birth-of-ida-and-a-lifelong-mission-fighting-light-pollution/.

[3] DarkSky International. (2025a). About DarkSky. https://darksky.org/about/#mission.

[4] DarkSky International (2025b). International Dark Sky Places. https://darksky.org/what-we-do/international-dark-sky-places/.

[5] Román, M. O., Wang, Z., Sun, Q., Kalb, V., Miller, S. D., Molthan, A., Schultz, L., Bell, J., Stokes, E. C., Pandey, B., Seto, K. C., Hall, D., Oda, T., Wolfe, R. E., Lin, G., Golpayegani, N., Devadiga, S., Davidson, C., Sarkar, S., Praderas, C., Schmaltz, J., Boller, R., Stevens, J., Ramos González, O. M., Padilla, E., Alonso, J., Detrés, Y., Armstrong, R., Miranda, I., Conte, Y., Marrero, N., MacManus, K., Esch, T., Masuoka, E. J. (2018). NASA’s Black Marble nighttime lights product suite. Remote Sensing of Environment, 210: 113-143, https://doi.org/10.1016/j.rse.2018.03.017.

[6] Marty, R., Vicente, G.S. (2025). Package ‘blackmarbler’. https://cran.r-project.org/web/packages/blackmarbler/blackmarbler.pdf.

[7] U.S. Census Bureau. 2024. 2024 Census TIGER/Line Shapefiles. https://data.census.gov

[8] Walker, K. , Rudis, B. (2025). Tigris: Load Census TIGER/Line shapefiles. R package version 2.2.1. https://cran.r-project.org/web/packages/tigris/index.html

[9] U.S. Census Bureau. (2023). Total Population.  American Community Survey 5-Year Estimates Detailed Tables. Table B01003.

[10] Walker, K., Herman, M. (2025). tidycensus: Load US Census Boundary and Attribute Data as ‘tidyverse’ and ‘sf’-Ready Data Frames. R package version 1.7.3, https://walker-data.com/tidycensus/.

[11] Friedl, M., Sulla-Menashe, D. (2022). MODIS/Terra+Aqua Land Cover Type Yearly L3 Global 500m SIN Grid V061. NASA EOSDIS Land Processes Distributed Active Archive Center. Accessed 2025-11-29 from https://doi.org/10.5067/MODIS/MCD12Q1.061. Accessed November 29, 2025.

[12] Schultz, J. (2022). States shut out light pollution. National Conference of State Legislatures. https://www.ncsl.org/environment-and-natural-resources/states-shut-out-light-pollution

[13] Flagstaff, Arizona. (2011). Flagstaff Zoning Code, Division 10-50.70: Outdoor Lighting Standards. Accessed via DarkSky.org, https://darksky.org/places/flagstaff-arizona-dark-sky-community/.

[14] Scranton, Pennsylvania. (2023). City of Scranton Zoning Ordinance, Article 5, Section 5.10 - Lighting and Glare.

[15] Village of Homer Glen, Illinois. (2010). An Ordinance Regulating Outdoor Lighting in the Village of Homer Glen. Accessed via DarkSky.org, https://darksky.org/places/homer-glen-illinois-dark-sky-community/.

[16] Belvidere, Illinois. (2025). Belvidere, Illinois - Code of Ordinances, Chapter 150, Article 7, Section 150.707. - Exterior lighting standards. https://library.municode.com/il/belvidere/codes/code_of_ordinances?nodeId=BELVIDERE_MUNICIPAL_CODE_CH150ZO_ART7PEST_S150.707EXLIST

[17] City of Bee Cave, Texas. (2025). City of Bee Cave, TX Code, Title II, Chapter UDC, Article 6, Section 6.2 - Lighting. https://ecode360.com/40281623

[18] Clanton, Alabama. (2025). Code of Ordinances. https://library.municode.com/al/clanton/codes/code_of_ordinances?nodeId=12327

[19] Stare, Jurij; Kyba, Christopher (2025): Radiance Light Trends. GFZ Data Services. https://doi.org/10.5880/GFZ.1.4.2019.001.

Footnotes

  1. Although land cover changes over the period from 2014 to 2024, for the purposes of this project, only one year of land cover data was considered for each community. Data availability is one reason for this simplification – annual data is not available for all study areas for all years, and the MODIS science team urges caution when using land cover layers for 2021 and beyond due to lack of updates to the classification training database [11]. Therefore, 2019 data was used for all cities except for Clanton, which used 2020 data.↩︎